实验一 8086虚拟IO接口
实验日期:2025/10/16
emu8086
emu8086软件
emu8086是款学习汇编语言的软件,它既可以用来编写汇编程序,也能对8086cpu的功能进行模拟,它并不操作真实的硬件寄存器或内存,而是操作自己内部的变量,从而达到软件层面的仿真。因此,它非常适合作为我们学习8086cpu汇编语言的工具。
除了使用emu8086,我们其实还可以使用虚拟机进行更加真实的模拟,这就需要下载安装古老的DOS操作系统,而这样的方式远远不如使用emu8086方便。
不过,emu8086仅支持8086cpu的模拟,因此,如果我们需要模拟其他型号的cpu,就需要寻找其他软件,比如PCem。
这次及之后的实验,我都将使用emu8086软件(版本4.08)作为实验环境。
界面及操作
进入emu8086,在新建文件时会有这样几个选项:

这四个选项分别表示四个模板:.COM文件、.EXE文件、二进制文件、引导扇区模板,它们决定了你所写的程序会被编译为哪种格式。对于初学者来说,COM template也许是最适合的选择,因为这是最简单直接的一个,程序和数据都会加载到同一个64KB的段内,不需要我们自己定义各个段的地址。
最下面的选项表示是否选择另一个更强大也更严格的编译器(Flat Assembler)进行编译,我们此处用不到这个功能,因此不需要勾选。
新建好文件后,我们能看到上方导航栏里有许多功能:

它们分别为:new(新建文件), open(打开文件), examples(代码示例), save(保存), compile(编译), emulate(模拟), calculator(计算器), convertor(逆向的计算器), options(选项), help(帮助),在上方还有 ascii codes 功能,可以快速查看ASCII码。
在本次实验中,我们只需用emulate功能对代码进行模拟,点击此按钮后能看到如下两个窗口,分别为源程序代码和模拟界面:

模拟界面的功能很多,此处只提最常用的几个功能:
run:一次运行全部代码。
single step:单步执行,每点击一次就往后执行一条指令。
reload:复位/重载,相当于还原回初始状态重新运行代码。
下方的按钮里,screen:显示虚拟屏幕,debug:修改寄存器、标志位的值,aux里的memory可以查看内存空间,stop on condition可以用来设置断点的条件,stack可以查看堆栈段的数据。不过,模拟界面本身已经把数据内容摆出来了,方便我们查看寄存器和内存数据的变化。
简单指令
大致了解了怎么使用emu8086之后,接下来通过一些简单的指令进行汇编语言的学习,同时顺便进一步学习计算机的内部结构。
0
在新建一个.COM模板的文件后,会看到预设好的如下代码:
; You may customize this and other start-up templates;
; The location of this template is c:\emu8086\inc\0_com_template.txt
org 100h
; add your code here
ret
分号;表示其后面的是注释。原始的这些注释可以直接删掉。
org指令是一条伪指令,它是Origin的缩写,表示指定程序的起始地址(段内偏移地址),所以一般只在程序的开头使用这一指令。如果没有使用org,那么程序会默认从0000H开始执行。
ret指令用于从子程序返回到调用它的程序,它通过修改ip的内容实现段内转移,在这里的作用相当于直接结束程序。
为了方便起见,接下来的代码都假定已经把这些原先设定的代码删掉了。
MOV
MOV作为可能是最为常用的操作码之一,负责数据的传送,可以将数据move进存储器或寄存器内。
通过这个指令,我们也能熟悉常用的几种寻址方式:立即寻址;直接寻址;寄存器寻址;寄存器间接寻址;寄存器相对寻址;基址、变址寻址;基址、变址、相对寻址。
代码1.
MOV AL, 16H
MOV BL, 10B
MOV CL, 16
MOV DL, 0FFH
对于立即数,有不同的表示方法。如果数字后无后缀,表示这是以十进制表示的,如代码块第3行,如果有后缀H,表示是十六进制(Hexadecimal的缩写),如第1行,如果后缀是B(Binary的缩写),表示是二进制,如第2行。需要注意的是,立即数的第一位必须是数字而不是字母,这样程序才能正常识别,所以需要对第一位原本是用字母表示的数的前面加个0,如第4行。
代码2.
MOV AX, 1234H
;MOV BL, 5678H
;MOV BX, 56789H
MOV BX, 56H
由于x86架构采用的是小端序,所以存取数据均遵循“低位数据存到低位,高位数据存到高位”的法则,故代码块第1行会将34H存进AL,将12H存进AH。而如果数据的大小大于对应的存储空间就会出错,故第2和第3行的代码均不能成功编译,但是第4行代码是正确的。

代码3.
MOV AX, 10H
MOV [10H], 11H
;MOV [AX], 22H
;MOV WORD PTR [AX], 22H
MOV BX, AX
;MOV [BX], 22H ;最好不要这样写
;MOV [BX+2], 22H ;最好不要这样写
MOV WORD PTR [BX], 22H
如上,AX属于寄存器寻址,10H属于立即寻址,[10H]属于直接寻址,[BX]属于寄存器间接寻址,[BX+2]属于寄存器相对寻址。
事实上,行3和行4的代码是无法正确编译的。这是因为在x86架构中规定了可以进行内存寻址的寄存器只有:BX,BP,SI,DI以及它们的组合,而AX,CX,DX均不在此列。所以,[AX],[AX+2],[AX+BX]等等都是不正确的。
而即使使用BX进行内存寻址,也不一定就正确了。行6和行7的代码在我这个版本的emu8086 上可以编译,但在其他编译器上就不一定能通过编译了。因为[BX]只指定了寻址地址,但没有指定数据类型是一个字节还是别的什么,而且作为立即数的22H也没有指定类型,所以这条指令是不明确的。一般情况下,如果两个操作数中有一个是类型明确的(比如AX等16位寄存器,明确是WORD类型的),而另一个类型不明确,那么就会按照那个明确的类型执行指令,比如MOV AX, 01H,会按照AX的类型进行WORD类型的操作。而在双方都模糊的情况下,我们最好使用ptr进行强制类型声明,这样就能明确类型了。
ptr的用法是在操作数前面加上“操作类型 ptr”,如行4和行8。
现在程序能正确编译了,但是在运行后,我们还会发现这样一个问题:源程序将10H存到AX后,AX内最终的数据应该是0010H才对,但实际上看到的数据是0000H。经过单步运行调试后,发现在执行最后一条指令MOV WORD PTR [BX], 22H后,AX内的值的确是0010H,但是紧随其后程序还多执行了一条“不存在”的指令将AX赋为了0000H。通过查看模拟界面右边显示的实际执行的指令,可以发现多了这样一条:AND AL, [BX+SI]。查看中间的内存界面,我发现这是因为在编写程序时更改了内存,而这个程序没有划分CS段和DS段,段地址都默认为0100H,所以指令和数据混在一起了。
如下图,代码块行8将0100:0010H处的赋为了0020H,而这个机器码恰好表示AND AL, [BX+SI]。

修改方式有很多,最好的方式是提前安排好段的区域,然后在程序末尾加上强制结束程序的指令,避免执行一些意料外的指令,也可以换成.EXE模板。
比如,可以把上面的代码改写成如下形式:
ASSUME CS:CODE, DS:DATA
DATA SEGMENT
;add data here
DATA ENDS
CODE SEGMENT
MOV AX, DATA
MOV DS, AX ;需要手动设置数据段的段地址
MOV AX, 10H
MOV [10H], 11H
MOV BX, AX
MOV WORD PTR [BX], 22H
CODE ENDS
编写程序
此次实验需要编写这样一个程序:
调用虚拟外设199端口,实现计数功能,在LED虚拟面板上显示100以内的所有偶数,并且每个数显示1秒钟。
调用虚拟外设
emu8086并不真正连接外部设备,而是模拟虚拟的外设。比如说会在电脑屏幕上显示一个LED面板窗口,以此演示真正LED面板的功能。
在emu8086安装路径的DEVICES文件夹内,我们能看到它能够启用的虚拟外设有哪些:

LED面板对应的就是LED_Display.exe这个程序。
要在汇编程序里使用这个外设,首先得启用它,再调用对应的端口:
#start=led_display.exe#
ASSUME CS:CODE
CODE SEGMENT
MOV AX, 1234
OUT 199, AX
CODE ENDS
其中,#这个字符并不是汇编语言指令,而是emu8086自身的特殊命令,表示模拟器预处理指令。#start=name.exe#表示启用对应的外设。
调用端口使用的是OUT指令,它可以将数值输出到相应的端口。另外,199这个端口对应的操作数只能是AX或AL。
此时运行程序,能看到虚拟LED屏上的数字变成了1234。

中断和延时
DOS全称为Disk Operating System,即硬盘操作系统,只关注硬盘,而BIOS全称为Basic Input Output System,即基本输入输出系统,同时接触硬件和软件,所以功能比DOS更多。
int指令会触发软件中断(内部中断),可以用来调用操作系统、BIOS提供的各种底层的功能。int指令的格式是“int 中断号”。中断号的范围是0-255,分别表示不同的功能。如中断号21H,表示调用DOS功能;15H,表示调用BIOS提供的系统服务中断。使用int 15H后,程序会根据AH的值判断需要做出的功能。例如,若AH的值为86H,则程序会提供微秒级的精确延时功能,延时时长为CX:DX(即CX的值乘以65535再加上DX的值)。因此,要延时一秒的话可以在CX里存0FH(即15),在DX里存4240H(即16960),故15x65535+16960=1000000微秒=1秒。
ASSUME CS:CODE
CODE SEGMENT
MOV AH, 86H
MOV CX, 000FH
MOV DX, 4240H
INT 15H
CODE ENDS
循环和跳转
要实现循环的程序,就需要用到跳转指令。程序跳转通常是由两条指令实现的,一个是cmp(compare缩写),另一个是jz/je/jne……等一系列跳转指令。
cmp会比较其后的两个操作数,并修改标志位。紧随其后的jump系列指令将根据这些标志位来判断是否满足跳转的条件。
jump系列的指令分为无条件跳转(jmp指令)和条件跳转。
常用的条件跳转指令有:jz(jump if zero),je(jump if equal),jg(jump if greater),jl(jump if less)……
例如,下面的代码能够实现循环跳转到function位置4次。
ASSUME CS:CODE
CODE SEGMENT
MOV AX, 0H
MOV CX, 0H
FUNCTION:
ADD AX, CX
ADD CX, 01H
CMP CX, 5
JL FUNCTION
CODE ENDS
实验程序
下面的程序能够实现:调用虚拟外设199端口,实现计数功能,在LED虚拟面板上显示100以内的所有偶数,并且每个数显示1秒钟。
#start=led_display.exe#
ASSUME CS:CODE
CODE SEGMENT
MOV CX, 0FH
MOV DX, 4240H
MOV BX, 0
MOV AX, 0
LED:
OUT 199, AX
MOV BX, AX
ADD BX, 2
MOV AH, 86H
INT 15H
MOV AX, BX
CMP BX, 100
JL LED
CODE ENDS
运行程序后的截图如下:
